iT邦幫忙

2024 iThome 鐵人賽

DAY 16
2
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 30

nestjs 系統設計 - 活動訂票管理系統 - client code part 4

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - client code part 4

目標

本日的目標會針對處理活動管理頁面作處理。

概念

這次的實做步驟,會先從一些資料存取的邏輯開始實做。然後開始實做 layout。最後邏輯放置對應的生命周期函數。

event 資料處理

1. 定義資料格式

import { ApiResponse } from './api';

export type EventResponse = ApiResponse<Event>;
export type EventListResponse = ApiResponse<{events: Event[], pageInfo: PageInfo}>;
export type DeleteEventResponse = ApiResponse<{id: string}>;
export type Event = {
  id: string
  name: string
  location: string
  totalTicketsPurchased: number
  totalTicketsEntered: number
  startDate: string
  createdAt: string
  updatedAt: string
}
export type PageInfo = {
  total: number
  offset: number
  limit: number
}

基本上有分為單一個 Event 與存取多個 Event 兩類。分別別使用 EventResponse 與 EventListResponse 來存放。

2. 讀取 event 的邏輯

import { DeleteEventResponse, EventListResponse, EventResponse } from '@/types/event';
import { Api } from './api';

async function createOne(name: string, location: string, date: string): Promise<EventResponse> {
  return Api.post('/events', {
    name: name,
    location: location,
    startDate: date
  });
}
async function getOne(eventId: string): Promise<EventResponse> {
  return Api.get(`/events/${eventId}`);
}
async function getAll(): Promise<EventListResponse> {
  return Api.get(`/events`);
}
async function updateOne(id: string, name: string, location: string, date: string): Promise<EventResponse> {
  return Api.patch(`/events/${id}`, { name, location, startDate: date});
}
async function deleteOne(id: string): Promise<DeleteEventResponse> {
  return Api.delete(`/events/${id}`);
} 
const eventService = {
  createOne,
  getOne,
  getAll,
  updateOne,
  deleteOne,
}

export { eventService }

把對 event 相關的操作封裝在 eventService 之內。把存取 api 的邏輯放在裡面。

3. 畫面處理

3.1 event list 畫面

import { Button } from '@/components/Button';
import { Divider } from '@/components/Divider';
import { HStack } from '@/components/HStack';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { useAuth } from '@/context/AuthContext';
import { eventService } from '@/services/event';
import { Event } from '@/types/event';
import { UserRole } from '@/types/user';
import { useFocusEffect } from '@react-navigation/native';
import { router, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { FlatList, TouchableOpacity } from 'react-native';
import Toast from 'react-native-root-toast';

export default function EventsScreen() {
  const {user} = useAuth();
  const navigation = useNavigation();
  const [isLoading, setIsLoading] = useState(false);
  const [events, setEvents] = useState<Event[]>([]);
  function onGotoEventPage(id: string) {
    if (user?.role === UserRole.Admin) {
      router.push(`/(events)/event/${id}`);
    }
  }
  function buyTicket(id: string) {
    try {
      // TODO: await ticket service
    } catch(error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
    }
  }
  const fetchEvents = async () => {
    try {
      setIsLoading(true);
      const response = await eventService.getAll();
      setEvents(response.data.events);
    } catch (error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
    } finally {
      setIsLoading(false);
    }
  };
  useFocusEffect(useCallback(()=>{
    fetchEvents()
  }, []))
  useEffect(() => {
    navigation.setOptions({
      headerTitle: 'Events',
      headerRight: user?.role === UserRole.Admin ? headerRight:null
    });
  }, [navigation, user]);
  return <VStack flex={1} p={20} pb={0} gap={20}>
      <HStack alignItems='center' justifyContent='center'>
        <Text fontSize={18} bold>{events.length} Events</Text>
      </HStack>
      <FlatList
        data={events}
        keyExtractor={({id})=> id}
        onRefresh={fetchEvents}
        refreshing={isLoading}
        ItemSeparatorComponent={()=> <VStack h={20}/>}
        renderItem={({item: event})=> (
          <VStack
            gap={20}
            p={20}
            style={{
              backgroundColor: 'white',
              borderRadius: 20,
            }}
            key={event.id}
          >
            <TouchableOpacity onPress={()=>onGotoEventPage(event.id)}>
              <HStack alignItems='center' justifyContent='space-between'>
                <HStack alignItems='center'>
                  <Text fontSize={26} bold>{event.name}</Text>
                  <Text fontSize={26} bold>|</Text>
                  <Text fontSize={16} bold>{event.location}</Text>
                </HStack>
                {user?.role === UserRole.Admin && <TabBarIcon size={24} name='chevron-forward'/>}
              </HStack>
            </TouchableOpacity>
            <Divider/>
            <HStack justifyContent='space-between'>
              <Text bold fontSize={16} color='gray'> Sold: {event.totalTicketsPurchased}</Text>
              <Text bold fontSize={16} color='green'> Entered: {event.totalTicketsEntered}</Text>
            </HStack>
            {user?.role === UserRole.Attendee && 
              <VStack>
                <Button
                  variant='outlined'
                  disabled={isLoading}
                  onPress={() => buyTicket(event.id)} 
                >
                  Buy Ticket
                </Button>
              </VStack>
            }
            <Text fontSize={13} color='gray'>{event.startDate}</Text>
          </VStack>
        )}
      />
    </VStack>
  ;
}
const headerRight = () => {
  return <TabBarIcon 
    size={32} 
    name='add-circle-outline'
    onPress={()=>router.push('/(events)/new')}
  />
}

這個畫面需要處理的是多個 event 的載入還有綁定一些對於單個 event 的事件操作。使用 useEffect 來處理 navigation 。使用 useFocusEffect 來處理每次觸發 api loading 多個 event 的動作。透過 icon 綁定載入單個 event 頁面的邏輯。

3.2 新增 event 的畫面

根據 手機平台切換不同 DatetimePicker 元件

import { Platform } from 'react-native';
import { HStack } from './HStack';
import { Text } from './Text';
import { Button } from './Button';
import RNDateTimePicker ,{ DateTimePickerAndroid } from '@react-native-community/datetimepicker';

interface DateTimePickerProps {
  onChange: (date: Date) => void;
  currentDate: Date;
}
export default function DateTimePicker(props: DateTimePickerProps) {
  if (Platform.OS === 'android') {
    return <AndroidDateTimePicker {...props}/>
  }
  if (Platform.OS === 'ios') {
    return <IOSDateTimePicker {...props}/>
  }
  return null;
}

export const AndroidDateTimePicker =({onChange, currentDate}: DateTimePickerProps ) => {
  const showDateTimePicker = () => {
    DateTimePickerAndroid.open({
      value: currentDate,
      onChange: (_, date?: Date) => onChange(date || new Date()),
      mode: 'date',
      minimumDate: new Date()
    });
  };
  return (
    <HStack p={10} alignItems='center' justifyContent='space-between'>
      <Text>{currentDate.toLocaleDateString()}</Text>
      <Button variant='outlined' onPress={showDateTimePicker}>Open Calendar</Button>
    </HStack>
  )
}

export const IOSDateTimePicker = ({onChange, currentDate}: DateTimePickerProps ) => {

  return (
    <RNDateTimePicker 
      style={{ alignSelf: 'flex-start'}}
      accentColor='black'
      minimumDate={new Date()}
      value={currentDate}
      mode='date'
      display='default'
      onChange={(_, date?: Date) => onChange(date || new Date())}
    />
  )
}

設定新增 Event 的畫面

import { Button } from '@/components/Button';
import DateTimePicker from '@/components/DatetimePicker';
import { Input } from '@/components/Input';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { eventService } from '@/services/event';
import { router, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import Toast from 'react-native-root-toast';

export default function NewEvent() {
  const navigation = useNavigation();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [name, setName] = useState('');
  const [location, setLocation] = useState('');
  const [date, setDate] = useState(new Date());

  async function onSubmit() {
    try {
      setIsSubmitting(true);
      await eventService.createOne(name, location, date.toISOString());
      router.back();
    } catch (error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
    } finally {
      setIsSubmitting(false);
    }
  }
  function onChangeDate(date?:Date) {
    setDate(date || new Date());
  }
  useEffect(()=>{
    navigation.setOptions({
      headerTitle: 'New Event',
    });
  }, [])
  return (
    <VStack m={20} flex={1} gap={30}>
      <VStack gap={5}>
        <Text ml={10} fontSize={14} color='gray'>
          Name
        </Text>
        <Input
          value={name}
          onChangeText={setName}
          placeholder='Name'
          placeholderTextColor='darkgray'
          h={48}
          p={14}
        />
      </VStack>
      <VStack gap={5}>
        <Text ml={10} fontSize={14} color='gray'>
          Location
        </Text>
        <Input
          value={location}
          onChangeText={setLocation}
          placeholder='Location'
          placeholderTextColor='darkgray'
          h={48}
          p={14}
        />
      </VStack>
      <VStack gap={5}>
        <Text ml={10} fontSize={14} color='gray'>
          Date
        </Text>
        <DateTimePicker
          onChange={onChangeDate}
          currentDate={date}
        />
      </VStack>
      <Button
        mt={'auto'}
        isLoading={isSubmitting}
        disabled={isSubmitting}
        onPress={onSubmit}
      >
        Save
      </Button>
    </VStack>
  );
}

3.3 載入單個 Event 的畫面

import { Button } from '@/components/Button';
import DateTimePicker from '@/components/DatetimePicker';
import { Input } from '@/components/Input';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { eventService } from '@/services/event';
import { Event } from '@/types/event';
import { useFocusEffect } from '@react-navigation/native';
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert } from 'react-native';
import Toast from 'react-native-root-toast';

export default function EventDetailScreen() {
  const navigation = useNavigation();
  const { id } = useLocalSearchParams();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [eventData, setEventData] = useState<Event|null>(null);
  function updateField(field: keyof Event, value: string|Date) {
    setEventData(prev => ({
      ...prev!,
      [field]: value
    }))
  }
  const onDelete = useCallback(async() => {
    if(!eventData) {
      return;
    }
    try {
      Alert.alert('Delete Event', 'Are you sure you want to delete this event?',[{
        text: 'Cancel',
      }, {
        text: 'Delete', onPress: async () => {
          setIsSubmitting(true);
          await eventService.deleteOne(id as string);
          router.back();
        }
      }]);
    } catch (error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
      router.back();
    } finally {
      setIsSubmitting(false);
    }
  }, [eventData, id]);
  async function onSubmitChanges() {
    if(!eventData) {
      return
    }
    try {
      setIsSubmitting(true);
      await eventService.updateOne(eventData.id, 
        eventData.name, 
        eventData.location, 
        eventData.startDate);
      router.back();
    } catch(error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
    } finally {
      setIsSubmitting(false);
    }
  }
  const fetchEvent = async ()=> {
    try {
      setIsSubmitting(true);
      const response = await eventService.getOne(id as string);
      setEventData(response.data);
    } catch (error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
      router.back();
    } finally {
      setIsSubmitting(false);
    }
  };
  useFocusEffect(useCallback(()=>{
    fetchEvent()
  }, []))
  useEffect(()=>{
    navigation.setOptions({
      headerTitle: '',
      headerRight: ()=> headerRight(onDelete)
    })
  }, [navigation, onDelete])
  return (
    <VStack m={20} flex={1} gap={30}>
      <VStack gap={5}>
        <Text ml={10} fontSize={14} color='gray'>
          Name
        </Text>
        <Input
          value={eventData?.name}
          onChangeText={(value)=>updateField('name', value)}
          placeholder='Name'
          placeholderTextColor='darkgray'
          h={48}
          p={14}
        />
      </VStack>
      <VStack gap={5}>
        <Text ml={10} fontSize={14} color='gray'>
          Location
        </Text>
        <Input
          value={eventData?.location}
          onChangeText={(value)=>updateField('location', value)}
          placeholder='Location'
          placeholderTextColor='darkgray'
          h={48}
          p={14}
        />
      </VStack>
      <VStack gap={5}>
        <Text ml={10} fontSize={14} color='gray'>
          Date
        </Text>
        <DateTimePicker 
          onChange={(value)=>updateField('startDate', value || new Date())}
          currentDate={new Date(eventData?.startDate|| new Date())}
        />
      </VStack>
      <Button
        mt={'auto'}
        isLoading={isSubmitting}
        disabled={isSubmitting}
        onPress={onSubmitChanges}
      >
        Save Change
      </Button>
    </VStack>
  );
}

const headerRight = (onPress: VoidFunction) => {
  return <TabBarIcon size={30} onPress={onPress} name='trash'/>
}

除了畫面的部份,這邊一樣使用 useFocusEffect 來處理畫面載入資後的資料處理。
使用 useEffect 來處理 navigation 與刪除事件的綁定。比較特別的是在刪除畫面的處理多一個 confirm 的頁面用來提醒 admin 即將刪除某個 event 才作刪除。

驗證

1. 新增 Event

  1. 建立粥節倫演唱會
    image
  2. 送出結果
    image

2. 修改 Event 日期為 10/1

  1. 選定 10/1/2024
    image
    image

3. 刪除 Event

  1. 選定後按刪除
    image
  2. 刪除後結果
    image

到此為止已經完了 admin 身份的操作

4. 登出以 attendee 身分登入

  1. 到 setting 按登出
    image
  2. 回到登入頁面
    image
  3. 登入一般使用者身份
    image
  4. 登入畫面
    image
    只有使用者能能看到買入

結論

目前實做了關於活動操作的部份,剩下票券的部份將在後面的章節繼續說明。


上一篇
nestjs 系統設計 - 活動訂票管理系統 - client code part 3
下一篇
nestjs 系統設計 - 活動訂票管理系統 - client code part 5
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言